feat(extensions): extension platform — host user-authored apps inside Agent Code - #577
feat(extensions): extension platform — host user-authored apps inside Agent Code#577Juliusolsson05 wants to merge 23 commits into
Conversation
Architecture settled — Stage 1 plan pushedFour consultants ran under orchestration run Decision: Stage 1 is compiled-in, built as Stage 2's substrate. What changed from the investigation's assumptionsStage 2's loader is one line, not a multi-week problem. Two objections to the iframe option were refuted. Paint order survives (the iframe wrapper The API is transport-independent. Same schemas for compiled-in, runtime-loaded, utility-process The two rules that make Stage 2 a swap
The plan ends with the grep that proves rule 1 held. If it fails, Stage 1 built something Stage 2 Stage 2 is conditional, not scheduledTrigger: extensions shipping to people who run packaged releases. The owner runs 📄 |
Stage 1 implemented — all six tasksSix commits, 28 files, +2668. Host built before its first consumer so the
What's in it
The test that mattersOnly the ABI types and its own file. The Timer directory should lift into its own repo in Deviations from the plan, both found by reading
Still openTwo reviewers running ( No manual smoke run yet — |
Full extension platform — Phase B complete16 commits, 42 files, +5528. The compiled-in app approach was removed; this is now a real VS Code-style Phase B
Verified, not assumedScheme spike — real built renderer, real CSP, Install — the real installer against a real repository: Plus error paths: unknown repo, private repo, malformed input. Manifest — the real timer manifest accepted, and nine rejection cases Production build — First real extension
It is already installed on this machine and will appear on next launch. Two bugs found by building the real thing
Not verifiedNobody has launched the app and clicked it. Everything above is automated |
…idate ledger rows Three known #577 defects, cleared before building the contribution wiring on top. 1. Update button was a no-op. onClick did `setRepo(entry.repo); void install()`, but setRepo is async and install() closed over the OLD repo, so Update installed the empty/last-typed value. install now takes an explicit target (`install(target?: string)`), and a new `update(entry)` calls install(entry.repo) directly — no closure round-trip. 2. Uninstall (and Update) never deactivated the live extension. AppsSettingsRow held no ExtensionHost reference and main has no handle on the renderer-side host, so a removed extension's subscriptions/registrations/intervals leaked for the session. The row now uses useExtensionHost() and calls deactivate(id) before removing files (remove) and before reinstalling over the bundle (update). deactivate works off the in-memory module, so it runs correctly before the on-disk bundle changes. 3. Ledger rows were cast unvalidated (`return parsed as InstalledExtension[]`). Each row's manifest.id/entry is interpolated into a path join and the import() URL, so a hand-edited extensions.json was the one way an unvalidated id/entry could reach those sinks. readLedger now validates every row against the manifest schema (which already enforces the id regex + entry `..`/absolute/backslash refinements) and drops malformed rows INDIVIDUALLY with a warning, never the whole ledger. First commit of the no-sandbox tranche (plan WS0). Verified: tsc -b tsconfig.node.json and tsc -p tsconfig.web.json --noEmit both clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion registry Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ngs systems Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n & capability enforcement Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b0a7baa to
cd4a61a
Compare
Iterating on an unpublished extension meant cutting a GitHub release for every change (installExtension resolves releases/latest). This adds a local-folder install so an author rebuilds, clicks Load folder, reloads — no release. - install.ts: extract the shared finalizeInstall tail (consent → move → ledger → grant) so GitHub and local installs converge; add installExtensionFromPath — a SNAPSHOT copy (cp excluding node_modules/.git) through the exact same manifest validation + entry-containment checks as the tarball path. No tarball to hash, so the grant binds to the built ENTRY bytes (a rebuild correctly forces re-consent). - ipc/extensions.ts: extract the consent dialog (shared by both paths); add extensions:install-path with a native openDirectory picker. - preload + AppsSettingsRow: extensionsInstallPath + a 'Load folder…' button. Snapshot, not a live mount — a live-reference mode is a larger scheme-handler change left for later; a copy reuses the tarball path's containment guarantees. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frame's activate() ran but its deactivate() never did: closing a view just navigated the iframe to about:blank, so the extension leaked its intervals / AudioContext / listeners on every close. The bootstrap now captures the module and, on pagehide (fired by that about:blank navigation and by app quit), calls module.deactivate() then disposes context.subscriptions in reverse order — the same cleanup contract the same-realm host honored. Best-effort/synchronous, since the document is being torn down (the timer's engine.dispose()/removeStyles() fit this). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sion.id in frame The SDK's AgentCodeApiV1 was Tier-0 only while the runtime had gained the Tier-1 observe groups — an author could not type api.workspace/sessions/panes.observe. Bumps the submodule to v0.2.0, which mirrors them. Also fills api.extension.id in the frame bootstrap (from the frame's own agent-code-ext://<id> origin host): the type promised it and the same-realm api already provided it; the frame did not. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…echanism) The extension modal was a fixed 560px, so a big game canvas could not get room without making every small extension look lost in an empty box. The frame now reports its content width alongside height; the iframe takes a definite width and the DialogContent is content-width (up to a cap), so a large view grows the modal to fit while a small one stays snug. Clamped 240-1200px against a hostile child. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An extension with a fixed natural size — a game canvas is the motivating case — reports e.g. 892x652. That fits a normal window, but on a short one the modal CLIPPED it: DialogContent has no maxHeight and is overflow-hidden, so the bottom of the view simply vanished with no scrollbar and no way to reach the controls. The frame now keeps its natural pixel size and is scaled visually to fit, with the wrapper occupying the scaled footprint so the auto-sized modal reserves the right room. Scaling rather than scrolling because the content is a single fixed-aspect surface: a scrollbar on a game is worse than a slightly smaller game, and clipping is worse than both. This has to live host-side. The obvious alternative — have the extension size itself in vw/vh — is a trap: those units resolve against the IFRAME's viewport, which the host sets from the content's reported size, so the extension's size would depend on its own size. That is precisely the resize feedback loop frameDocument.ts's measurement is built to avoid.
reportSize measured root.scrollWidth, but #root is width:100% and scrollWidth is by definition never smaller than clientWidth — so the reported width was always at least the CURRENT iframe width. The modal could grow and never shrink: switching from a wide view to a narrow one (an 892px game to a 375px one) left the modal frozen at the old width with the new content marooned in the corner. Width now comes from the children, which carry their own intrinsic size, so the report can go down as well as up. Height still comes from #root, which is height:auto and therefore already measures content. The ResizeObserver had the matching blind spot: watching #root alone cannot see a content change that only alters WIDTH, because #root's own box is pinned to 100% and never moves. It now observes the children too, and a MutationObserver re-syncs that list when the extension swaps its tree — a router switching screens replaces the child outright, which is exactly the case that was stale.
…ack palettes The frame's only baked CSS is a margin/overflow reset — there is no theme baseline — and onLoad pushed mount synchronously while the theme followed a microtask later, because tokens() is async. So a view rendered against --theme-* variables that did not exist yet. Given that, a local fallback palette was the only defense against a flash of unstyled content, which is why extension authors end up re-declaring the entire token set locally instead of using var(--theme-canvas) directly. The platform never offered a moment at which the theme was guaranteed present. postMessage preserves order, so pushing theme first and mount second closes the window: by the time the child mounts, the tokens are already set on its documentElement. The catch is deliberate — a theme failure must never prevent the view from mounting, since an unthemed extension is a bug but an unmounted one is a broken product.
Brings the branch up to date after 40 commits landed on main (command palette sort modes, unified command settings, UI primitive theme fidelity, dictation history, dispatch lane removal, session lifecycle observability). 14 files were touched by both sides; 4 genuinely conflicted, all of the "re-apply our small change onto their rewrite" kind: - CommandKeybindingsRow.tsx — main rewrote it (+237) for the unified command list and Palette column. Both sides added real behaviour, so both survive: extension-contributed keybinding defaults still merge into the table the editor conflict-checks against, and the deps array is now the union. - settingsRegistry.ts — main removed the Commands category (it moved into CommandKeybindingsRow), taking listPickerCommandMeta with it. Kept main's shape and re-applied only what extensions still need: the `extension` control arm and deriveExtensionSettings. getSettingsRegistry drops its now-dead extensionCommands parameter, and SettingsPage was updated with it — the params were positional, so leaving it would have passed commands where manifests were expected and silently rendered nothing. - registry.ts — took main's removal of PickerCommandMeta; our per-call allCommandDefs concat was outside the conflict and survives. - SettingsList.tsx — both added a marker row; kept both. Two things git merged cleanly but wrongly, caught by tsc rather than by review: the extension keybinding imports in registry.ts were removed with main's hunk while the code using them survived, and deriveExtensionSettings lost its definition while keeping its call site. One real integration gap: main's new `grouped` sort mode sections the palette by CommandCategory, and our `extensions` category had no entry in CATEGORY_ORDER or CATEGORY_LABELS, so extension commands would have grouped under an unnamed heading. Extensions sort last — third-party commands should not sit above the app's own, matching why they are concatenated last in the registry. Also fixes the SESSION_KINDS parity test, which was already red on this branch before the merge. It now asserts the derivation rather than a hand-written list, which is what the guard was always for. Review finding H3 (extension-view being a SessionKind at all) stays open and is documented at the assertion. Verified: tsc clean on both projects, 1818/1818 tests passing.
Six boundary defects, each independently confirmed by two or more reviewers. Scheme-handler traversal (blocker). `url.hostname` was used verbatim as a path segment, and the containment check below it resolves against a root DERIVED FROM THAT VALUE — so an escape made the check certify the wrong root and approve everything beneath it. `agent-code-ext://../extension-grants.json` parsed to hostname `..`, rooting the handler at the whole state directory: grants, ledger, workspace.json, and the proxy dumps that carry provider Authorization headers. Reachable from a Tier-0 extension that triggered no consent dialog. The id is now validated before anything else touches it. The pattern lived in four files and was missing from the one that mattered most, so it now lives in @shared/types/extensionId and everything imports it. That also fixes removeExtension, which ran a recursive rm on an unvalidated, IPC-supplied path component. HTML injection into the frame document. The bootstrap interpolated viewId and entry into JS source and relied on JSON.stringify, which escapes quotes and backslashes but not `<` or `/` — so a value containing `</script>` closed the element from inside a string literal. The entry path passed all four of the manifest's negative refinements with that payload embedded. Config now travels in a JSON island with `<` escaped, which removes the sink rather than filtering it, and the view id is checked against the manifest's declared views. Covered by a test using the reviewer's exact payload. Child CSP was per-SCHEME, not per-origin. `'self'` already covers the document's own origin, so the bare `agent-code-ext:` source granted only the cross-extension case — one extension could fetch and execute another's bundle. Paired with a wildcard CORS header on every asset. Both are now scoped to the frame's own origin. base-uri and form-action are set explicitly because neither falls back to default-src. window.open egress. No CSP directive governs window.open, so a Tier-0 extension could exfiltrate to any URL through the OS browser with no prompt. Fixed with sandbox="allow-scripts allow-same-origin", which keeps the origin-derived broker identity intact while killing popups, top-nav, modals, forms, and downloads. The comment claiming sandbox was unusable was wrong — that is only true without allow-same-origin. Enforced on the attribute rather than in setWindowOpenHandler because Electron's HandlerDetails has no frame field and referrer is suppressible. viewBridge's side channel authenticated on one gate while claiming parity with frameHost's two. contentWindow is identity-stable across navigations, so source alone could not tell a self-navigated frame from the original; it now checks origin too. __proto__ storage keys silently vanished instead of failing, because the write hit Object.prototype's setter rather than creating an own property. Adds 12 tests over a boundary that previously had none.
Undo Close was permanently brickable (the review's only CRITICAL). Both undo
paths called spawn() for every kind, main rejects an extension-view spawn, the
catch turned that into 'retryable-failure', and undoClose PUSHES A FAILED ENTRY
BACK — poisoning the stack head so every later Cmd+Shift+T popped the same
entry, failed, re-pushed, and returned. All older undo history became
unreachable for the rest of the session. Scenario: split a panel view into a
pane, close it, then accidentally close a Claude pane — undo never works again.
The tab path was worse. It spawns leaves in order, so hitting an extension-view
leaf threw partway through and the rollback then killed every sibling it had
just spawned: undoing a tab containing one extension pane started N real
claude/codex processes plus proxies, killed them all, restored nothing, and
poisoned the stack. A process-less leaf is now restored rather than spawned, and
deliberately not added to spawnedIds so rollback does not try to kill it.
Bury/revive and detach/attach stranded a pane forever. isSessionKind accepts
'extension-view', so ensureSessionLive sailed past its unsupported-kind guard,
called recoverSession, and threw on main's rejection. Bury and
Detach-to-Dispatch are both ungated commands, making it a one-way trip with Kill
Buried as the only exit. Fenced at that single choke point, which covers all
three callers (Revive, Attach Detached, Attach All).
A rehydrated pane claimed its extension was missing. installedExtensions starts
empty and is filled by an async IPC whose failure path deliberately leaves the
store untouched, so "still loading" and "not installed" were the same state:
every reload flashed a false message, and one failed extensionsList() made it
permanent — sending the user to reinstall something that was fine. The store now
records whether the list ever loaded.
Extension panes could not be focused by clicking them. The leaf declared
focused/onFocusRequest in Props and used neither, while every sibling leaf wires
onMouseDown — so clicking an extension pane left focus elsewhere and Cmd+W closed
the wrong pane. The cross-origin iframe still swallows mousedown over its own
content, so this catches the surrounding gutter: partial, but strictly better
than unfocusable.
A panel view's action command opened as a modal, contradicting its manifest,
because the cold-activation fallback called openApp() unconditionally while the
targetView branch beside it already honoured the declared mount.
Also validates the persisted extensionViewId against the manifest's declared
views. It is an unconstrained string restored from workspace.json, and trusting
everything after split('.')[0] let any "victim.anything" mount a live broker for
victim.
The host document permitted agent-code-ext: in script-src, connect-src, and
every asset directive. That existed solely to serve ExtensionHost.activate() —
a host-realm import('agent-code-ext://…') that evaluates third-party extension
code in the renderer's own realm, where window.api exposes every IPC handler
including extensions:install and extensions:remove. That path has no callers;
command execution moved into the sandboxed frame. So the concession bought
nothing and left the renderer permitted to run extension code directly, which
is precisely what the iframe design exists to prevent.
The scheme now appears in frame-src and nowhere else. The child serves its own
assets under its own far stricter policy. The old rationale block described
directives that no longer grant it, so it is gone rather than left to mislead;
what replaces it says that frame-src is the directive doing the containment
work, which the previous comment never mentioned.
Install staged into the OS temp dir and committed with rename(staging, final).
rename cannot cross filesystems, so wherever /tmp is its own mount — Linux
tmpfs, the common case — it fails with EXDEV. And since the commit deletes the
live bundle BEFORE renaming, that is not "install failed" but "install
destroyed the version you had". Staging is now a sibling of the destination, so
the rename is same-filesystem by construction.
…switch The gate was `await requireGrant(...)` lines sprinkled through a switch. Adding a Tier-2/3 member to frameRequestSchema and forgetting its line was a one-line, review-invisible privilege escalation — nothing existed to notice the omission. And `perform` had no default arm, so an unhandled method fell off the end returning undefined, which the caller reported as ok:true — a silent false success for a capability that was never performed. Tiering is now a Record keyed by the method union, enforced once before the dispatch, plus an exhaustiveness assert. Verified by adding a `network.fetch` member to the schema: it fails to compile in both places, so an ungated method can no longer be expressed rather than merely being caught in review.
Builds the platform for user-authored workflow tools that live inside Agent Code —
installed by the user, invoked from the command palette, opening their own page, backed
by a documented app API.
Reference case is a timer app. The timer is the forcing function, not the goal; the goal
is app-integrated extensions that can see and act on workspace, session, and git state
without anyone editing core app source.
This PR holds the full implementation. The first commit is investigation evidence, not
the plan — the plan file lands once the architecture decision is settled.
Status
What the investigation found
packages/workflow-mcpis already a complete extension host. Discovery,acornmetadata parsing without execution, SHA-256 integrity, an approval store keyed to
canonicalIdentity + sourceHash, anode:vmsandbox, and a separateutilityProcesswithheartbeats and timeouts — all shipping today. What it has no story for is UI.
So the open question is narrower than it looked: not how to host user code, but how
user-authored UI reaches the screen.
Three candidates, unresolved:
src/apps/<id>/The constraints that decide it are in
docs/superpowers/specs/2026-07-20-extension-platform-investigation.md§3–§8. The twonon-obvious ones:
index — neither survives a frame boundary, which is a direct cost against C.
twice and app chunks are content-hashed, so every host object must be passed in.
Notes
window.apiis never handed to an extension — 153 flat methods, no namespacing, nosender validation. Extensions get a narrow zod-validated bridge instead, following the
remote protocol's "the union is the allow-list" principle.
Settingsstore; forgetting theversion bump shipped a black-screen launch bug twice (Add settings for command picker visibility #249).
extensions agent capabilities, this gives them app capabilities.
🤖 Generated with Claude Code